Skip to main content

copp\copp\copp3\opt3/
topp3_lp.rs

1//! 3rd-order Time-Optimal Path Parameterization (TOPP3) based on linear programming (LP).
2//!
3//! # Method identity
4//! This module implements the **optimization backend** for TOPP3-LP by transforming
5//! third-order path-parameterization constraints/objective into Clarabel-compatible
6//! conic form and solving with LP.
7//!
8//! # Discrete variables (local notation)
9//! On a path grid `s[0..=n]`:
10//! - `a[k]` denotes $\dot{s}_k^2$;
11//! - `b[k]` denotes $\ddot{s}_k$;
12//! - decision vector is organized as `x = [a[0..=n], b[0..=n]]`.
13//!
14//! # High-level pipeline
15//! 1. Validate boundary/index contracts.
16//! 2. Assemble standard TOPP3 conic constraints.
17//! 3. Build sparse matrices `A`, `P`, vector `q`, and solve by Clarabel.
18//! 4. Apply status acceptance policy ([`ClarabelOptions::is_allow`](crate::solver::copp2_socp::ClarabelOptions::is_allow)) and extract
19//!    a [`Topp3Profile`](crate::solver::topp3_lp::Topp3Profile) only when accepted.
20//!
21//! # API layering
22//! - [`topp3_lp`](crate::solver::topp3_lp::topp3_lp): strict/normal API, returns only accepted [`Topp3Profile`](crate::solver::topp3_lp::Topp3Profile).
23//! - [`topp3_lp_expert`](crate::solver::topp3_lp::topp3_lp_expert): expert API returning `(Option<Topp3Profile>, DefaultSolution<f64>)`.
24//! - [`topp3_lp_expert_with_info`](crate::solver::topp3_lp::topp3_lp_expert_with_info): expert API plus Clarabel linear-solver
25//!   metadata for wrappers that need solver-side diagnostics.
26
27use crate::copp::copp3::Topp3Profile;
28use crate::copp::copp3::formulation::{Topp3Problem, get_weight_a_topp3};
29use crate::copp::copp3::opt3::ClarabelExpertInfor3rd;
30use crate::copp::copp3::opt3::clarabel_constraints::{
31    clarabel_standard_capacity_topp3, clarabel_standard_constraint_topp3,
32};
33use crate::copp::{ClarabelOptions, clarabel_to_copp3_solution};
34use crate::diag::{
35    CoppError, DebugVerboser, SilentVerboser, SummaryVerboser, TraceVerboser, Verboser, Verbosity,
36    check_boundary_state_copp3_valid, check_s_interval_valid, format_duration_human,
37};
38use clarabel::algebra::CscMatrix;
39use clarabel::solver::{DefaultSolution, DefaultSolver, IPSolver, SupportedConeT};
40use core::f64;
41
42/// Strict TOPP3-LP API for production use.
43///
44/// # Purpose
45/// Use this entry when caller only needs a valid [`Topp3Profile`](crate::solver::topp3_lp::Topp3Profile) and treats
46/// non-accepted solver statuses as hard failures.
47///
48/// # Contract
49/// - Internally calls [`topp3_lp_expert`](crate::solver::topp3_lp::topp3_lp_expert).
50/// - Returns `Ok(Topp3Profile { .. })` **iff** `options.is_allow(solution.status)` is `true`.
51/// - Returns `Err(CoppError::ClarabelSolverStatus(...))` when status is not accepted.
52///
53/// # Returns
54/// Returns accepted TOPP3 profile.
55///
56/// # Errors
57/// Returns [`CoppError`](crate::diag::CoppError) on model/solver failures and non-accepted solver status.
58///
59/// More details are provided in the documentation of [`topp3_lp_expert`](crate::solver::topp3_lp::topp3_lp_expert).
60pub fn topp3_lp(
61    problem: &Topp3Problem,
62    options: &ClarabelOptions,
63) -> Result<Topp3Profile, CoppError> {
64    let (result, solution) = topp3_lp_expert(problem, options)?;
65    result.ok_or_else(|| CoppError::ClarabelSolverStatus("topp3_lp".into(), solution.status))
66}
67
68/// Expert TOPP3-LP API with full Clarabel solution exposure.
69///
70/// # Return contract
71/// - `Ok((Some(result), solution))`: status accepted by `options.is_allow(solution.status)`.
72/// - `Ok((None, solution))`: solve finished but status not accepted.
73/// - `Err(...)`: input/model/solver-construction runtime failures.
74///
75/// # Returns
76/// Returns tuple `(Option<Topp3Profile>, DefaultSolution<f64>)` for diagnostics.
77///
78/// # Errors
79/// Returns [`CoppError`](crate::diag::CoppError) only for real build/runtime failures.
80///
81/// # Contract
82/// - caller handles `None` profile when status is not accepted;
83/// - acceptance policy is fully defined by `options.is_allow`.
84///   See [`ClarabelOptions::is_allow`](crate::solver::topp3_lp::ClarabelOptions::is_allow)
85///   for a status-handling example.
86///
87/// # Verbosity behavior
88/// Logging is layered by `options.verbosity()`:
89/// - [`Silent`](Verbosity::Silent): no algorithm logs;
90/// - [`Summary`](Verbosity::Summary): lifecycle milestones and elapsed time;
91/// - [`Debug`](Verbosity::Debug): assembly-level counters and stage summaries;
92/// - [`Trace`](Verbosity::Trace): fine-grained stage deltas and solver snapshot diagnostics.
93pub fn topp3_lp_expert(
94    problem: &Topp3Problem,
95    options: &ClarabelOptions,
96) -> Result<(Option<Topp3Profile>, DefaultSolution<f64>), CoppError> {
97    let info = topp3_lp_expert_with_info(problem, options)?;
98    let _ = &info.linsolver;
99    Ok((info.result, info.solution))
100}
101
102/// Expert TOPP3-LP API with Clarabel solution and linear-solver diagnostics.
103///
104/// Use this variant when callers need more than
105/// [`DefaultSolution`](clarabel::solver::DefaultSolution), because Clarabel stores linear-solver metadata on the
106/// solver `info` object rather than inside the returned solution.
107///
108/// Status acceptance follows
109/// [`ClarabelOptions::is_allow`](crate::solver::topp3_lp::ClarabelOptions::is_allow);
110/// see that method for the shared status-handling pattern.
111pub fn topp3_lp_expert_with_info(
112    problem: &Topp3Problem,
113    options: &ClarabelOptions,
114) -> Result<ClarabelExpertInfor3rd, CoppError> {
115    match options.verbosity() {
116        Verbosity::Silent => topp3_lp_core(problem, (options, SilentVerboser)),
117        Verbosity::Summary => topp3_lp_core(problem, (options, SummaryVerboser::new())),
118        Verbosity::Debug => topp3_lp_core(problem, (options, DebugVerboser::new())),
119        Verbosity::Trace => topp3_lp_core(problem, (options, TraceVerboser::new())),
120    }
121}
122
123/// Core implementation for TOPP3-LP expert flow.
124///
125/// # Internal contract
126/// `options_verboser` packs:
127/// - `options`: acceptance policy and Clarabel numerical settings;
128/// - `verboser`: concrete logger implementation chosen by external verbosity dispatch.
129///
130/// # Invariants
131/// - decision-variable layout is always `x = [a[0..=n], b[0..=n]]`;
132/// - extracted `(a,b)` is produced only through [`clarabel_to_copp3_solution`](crate::solver::copp3_socp::clarabel_to_copp3_solution) when status is accepted.
133fn topp3_lp_core(
134    problem: &Topp3Problem,
135    options_verboser: (&ClarabelOptions, impl Verboser),
136) -> Result<ClarabelExpertInfor3rd, CoppError> {
137    let (options, mut verboser) = options_verboser;
138    let idx_s_start = problem.idx_s_start;
139    let a_boundary = problem.a_boundary;
140    let b_boundary = problem.b_boundary;
141    let num_stationary = problem.num_stationary;
142    if verboser.is_enabled(Verbosity::Summary) {
143        verboser.record_start_time();
144    }
145    if verboser.is_enabled(Verbosity::Trace) {
146        let settings = options.clarabel_settings();
147        crate::verbosity_log!(
148            crate::diag::Verbosity::Summary,
149            "topp3_lp: options snapshot -> allow(almost={}, max_iter={}, max_time={}, callback_term={}, insufficient_progress={}), tol_gap_rel={}, tol_feas={}, max_iter={}, verbose={}",
150            options.is_allow(clarabel::solver::SolverStatus::AlmostSolved),
151            options.is_allow(clarabel::solver::SolverStatus::MaxIterations),
152            options.is_allow(clarabel::solver::SolverStatus::MaxTime),
153            options.is_allow(clarabel::solver::SolverStatus::CallbackTerminated),
154            options.is_allow(clarabel::solver::SolverStatus::InsufficientProgress),
155            settings.tol_gap_rel,
156            settings.tol_feas,
157            settings.max_iter,
158            settings.verbose
159        );
160    }
161
162    // Check input validity
163    check_boundary_state_copp3_valid(a_boundary, b_boundary)?;
164    let n = problem.a_linearization.len() - 1;
165    let idx_s_final = idx_s_start + n;
166    if verboser.is_enabled(Verbosity::Summary) {
167        crate::verbosity_log!(
168            crate::diag::Verbosity::Summary,
169            "\ntopp3_lp started: {} <= idx_s <= {}, s_len = {}, num_stationary={:?}.",
170            idx_s_start,
171            idx_s_final,
172            problem.a_linearization.len(),
173            num_stationary
174        );
175    }
176    check_s_interval_valid("topp3_lp", idx_s_start, idx_s_final)?;
177    // Let x = [a[0,1,...,n], b[0,1,...,n]] \in R^{2*(n+1)}.
178    // Step 1. Deal with constraints
179    // s=b-A*x \in cone, where A[row[i],col[i]]=val[i], A \in R^{m*(n+1)}, b \in R^m, s \in R^m
180    // -s=-b+A*x
181    // Step 1.1 create constraints
182    let (capacity_val, capacity_b, capacity_cones) =
183        clarabel_standard_capacity_topp3(problem.constraints, (idx_s_start, idx_s_final));
184    if verboser.is_enabled(Verbosity::Debug) {
185        crate::verbosity_log!(
186            crate::diag::Verbosity::Summary,
187            "topp3_lp: capacity estimate standard(val={capacity_val}, b={capacity_b}, cone={capacity_cones}), n_var={}",
188            2 * (n + 1)
189        );
190    }
191    let mut cones = Vec::<SupportedConeT<f64>>::with_capacity(capacity_cones);
192    let mut row = Vec::<usize>::with_capacity(capacity_val);
193    let mut col = Vec::<usize>::with_capacity(capacity_val);
194    let mut val = Vec::<f64>::with_capacity(capacity_val);
195    let mut b = Vec::<f64>::with_capacity(capacity_b);
196    if verboser.is_enabled(Verbosity::Trace) {
197        crate::verbosity_log!(
198            crate::diag::Verbosity::Summary,
199            "topp3_lp: allocated capacities row/col/val/b/cones <= {capacity_val}/{capacity_val}/{capacity_val}/{capacity_b}/{capacity_cones}",
200        );
201    }
202
203    // Step 1.2 deal with standard constraints
204    let s = problem.constraints.s_vec(idx_s_start, idx_s_final + 1)?;
205    let row_before_std = row.len();
206    let col_before_std = col.len();
207    let val_before_std = val.len();
208    let b_before_std = b.len();
209    let cones_before_std = cones.len();
210    clarabel_standard_constraint_topp3(
211        problem,
212        &s,
213        (&mut row, &mut col, &mut val, &mut b, &mut cones),
214        num_stationary,
215        &verboser,
216    )?;
217    if verboser.is_enabled(Verbosity::Trace) {
218        crate::verbosity_log!(
219            crate::diag::Verbosity::Summary,
220            "topp3_lp: standard-constraints delta row/col/val/b/cones = +{}/+{}/+{}/+{}/+{}",
221            row.len() - row_before_std,
222            col.len() - col_before_std,
223            val.len() - val_before_std,
224            b.len() - b_before_std,
225            cones.len() - cones_before_std
226        );
227    }
228
229    // Step 1.3 build the constraints
230    let n_var = 2 * (n + 1);
231    let row_len = row.len();
232    let col_len = col.len();
233    let val_len = val.len();
234    let b_len = b.len();
235    let cones_len = cones.len();
236    let a_csc = CscMatrix::new_from_triplets(b.len(), n_var, row, col, val);
237    // Step 2. objective function. max: \int a(s) ds
238    let p_object = CscMatrix::<f64>::zeros((n_var, n_var));
239    let q_object = clarabel_q_object_topp3_lp(&s, num_stationary, n_var);
240    if verboser.is_enabled(Verbosity::Trace) {
241        let (q_min, q_max) = q_object
242            .iter()
243            .fold((f64::INFINITY, f64::NEG_INFINITY), |(mn, mx), &v| {
244                (mn.min(v), mx.max(v))
245            });
246        crate::verbosity_log!(
247            crate::diag::Verbosity::Summary,
248            "topp3_lp: matrix built with m={}, n={}, A.nnz={}, P.nnz={}, q_range=[{}, {}]",
249            b_len,
250            n_var,
251            a_csc.nnz(),
252            p_object.nnz(),
253            q_min,
254            q_max
255        );
256    }
257    if verboser.is_enabled(Verbosity::Summary) {
258        crate::verbosity_log!(
259            crate::diag::Verbosity::Summary,
260            "topp3_lp: ready to solve with row/col/val/b/cones = {row_len}/{col_len}/{val_len}/{b_len}/{cones_len} and n_var = {n_var}.",
261        );
262    }
263    // Step 3. solve the LP problem
264    let settings = options.clarabel_settings().clone();
265    let mut solver = DefaultSolver::<f64>::new(&p_object, &q_object, &a_csc, &b, &cones, settings)
266        .map_err(|e| CoppError::ClarabelSolverError("topp3_lp".into(), e))?;
267    solver.solve();
268    let linsolver = solver.info.linsolver.clone();
269    let solution = solver.solution;
270    if verboser.is_enabled(Verbosity::Summary) {
271        crate::verbosity_log!(
272            crate::diag::Verbosity::Summary,
273            "topp3_lp: solve done, status = {:?}, elapsed = {}.",
274            solution.status,
275            format_duration_human(verboser.elapsed())
276        );
277    }
278    if verboser.is_enabled(Verbosity::Trace) {
279        let show = solution.x.len().min(3);
280        crate::verbosity_log!(
281            crate::diag::Verbosity::Summary,
282            "topp3_lp: solution x_len={}, head={:?}",
283            solution.x.len(),
284            &solution.x[0..show]
285        );
286    }
287    let result = if options.is_allow(solution.status) {
288        Some(clarabel_to_copp3_solution(
289            &solution.x.as_slice()[0..2 * (n + 1)],
290            &s,
291            num_stationary,
292        ))
293    } else {
294        None
295    };
296    if verboser.is_enabled(Verbosity::Trace) {
297        crate::verbosity_log!(
298            crate::diag::Verbosity::Summary,
299            "topp3_lp: allow(status)={}, extracted_profile={}",
300            options.is_allow(solution.status),
301            if result.is_some() {
302                "Some(Topp3Profile)"
303            } else {
304                "None"
305            }
306        );
307    }
308    Ok(ClarabelExpertInfor3rd {
309        result,
310        solution,
311        linsolver,
312    })
313}
314
315/// Build LP objective vector for TOPP3-LP in Clarabel form.
316///
317/// # Definition
318/// The primal objective is `max \int a(s) ds`, converted to minimization as
319/// `min \int -a(s) ds`.
320///
321/// # Layout
322/// - first block (`a`) gets negated quadrature weights;
323/// - second block (`b`) is zero-padded.
324#[inline(always)]
325fn clarabel_q_object_topp3_lp(s: &[f64], num_stationary: (usize, usize), n_var: usize) -> Vec<f64> {
326    let mut q_object = get_weight_a_topp3(s, num_stationary);
327    // max \int a(s) ds <=> min \int -a(s) ds
328    q_object.iter_mut().for_each(|q_i| *q_i = -*q_i);
329    q_object.resize(n_var, 0.0);
330    q_object
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use crate::copp::ClarabelOptionsBuilder;
337    use crate::copp::InterpolationMode;
338    use crate::copp::copp2::stable::basic::{Topp2ProblemBuilder, s_to_t_topp2};
339    use crate::copp::copp2::stable::reach_set2::ReachSet2OptionsBuilder;
340    use crate::copp::copp2::stable::topp2_ra::topp2_ra;
341    use crate::copp::copp3::stable::basic::{Topp3ProblemBuilder, s_to_t_topp3, t_to_s_topp3};
342    use crate::path::{add_symmetric_axial_limits_for_test, lissajous_path_for_test};
343    use crate::robot::robot_core::Robot;
344    use std::time::Instant;
345
346    #[test]
347    fn test_topp3_lp() -> Result<(), CoppError> {
348        run_test_topp3_lp_repeated(1, false)
349    }
350
351    /// Conditions: release, --include-ignored, CPU = Intel(R) Core(TM) Ultra 9 285K.
352    /// Average over 100 experiments: tc_ra = 0.3417 ms, tc_lp = 261.8795 ms, tf_ra = 6.138643, tf_lp = 7.051755
353    #[test]
354    #[ignore = "slow"]
355    fn test_topp3_lp_robust() -> Result<(), CoppError> {
356        run_test_topp3_lp_repeated(100, true)
357    }
358
359    fn run_one_topp3_lp_case(
360        options_lp: &ClarabelOptions,
361    ) -> Result<(f64, f64, f64, f64, f64, usize), CoppError> {
362        let n: usize = 1000;
363        let dim = 7;
364        let mut rng = rand::rng();
365        let (s, path, _, _) =
366            lissajous_path_for_test(dim, n, &mut rng).expect("random range is valid");
367
368        let mut robot = Robot::with_capacity(dim, n);
369        robot
370            .with_s(&s.as_view())?
371            .with_q_from_path_3rd(&path, 0, n)?;
372        add_symmetric_axial_limits_for_test(&mut robot, 1.0, 1.0, Some(5.0))?;
373
374        let topp2_problem = Topp2ProblemBuilder::new(&robot, (0, n - 1), (0.0, 0.0)).build()?;
375        let start = Instant::now();
376        let options_ra = ReachSet2OptionsBuilder::new()
377            .lp_feas_tol(1E-9)
378            .a_cmp_abs_tol(1E-9)
379            .a_cmp_rel_tol(1E-9)
380            .build()?;
381        let a_profile_ra = topp2_ra(&topp2_problem, &options_ra)?;
382        let time_topp_ra = start.elapsed().as_secs_f64() * 1E3;
383        let (t_motion_ra, _) = s_to_t_topp2(s.as_slice(), &a_profile_ra, 0.0)?;
384
385        let start = Instant::now();
386        robot.constraints.amax_substitute(&a_profile_ra, 0)?;
387        let topp3_problem =
388            Topp3ProblemBuilder::new(&mut robot, 0, &a_profile_ra, (0.0, 0.0), (0.0, 0.0))
389                .with_num_stationary_max(2)
390                .build_with_linearization()?;
391        let profile = topp3_lp(&topp3_problem, options_lp)?;
392        let time_topp3_lp = start.elapsed().as_secs_f64() * 1E3;
393        let start = Instant::now();
394        let (t_motion_lp, t_s) = s_to_t_topp3(s.as_slice(), profile.as_parts(), 0.0)?;
395        let s_t = t_to_s_topp3(
396            s.as_slice(),
397            profile.as_parts(),
398            &t_s,
399            InterpolationMode::UniformTimeGrid(0.0, 1E-3, true),
400        )?;
401        let time_interpolation = start.elapsed().as_secs_f64() * 1E3;
402        Ok((
403            time_topp_ra,
404            time_topp3_lp,
405            time_interpolation,
406            t_motion_ra,
407            t_motion_lp,
408            s_t.len(),
409        ))
410    }
411
412    fn run_test_topp3_lp_repeated(n_exp: usize, flag_print_step: bool) -> Result<(), CoppError> {
413        let options_lp = ClarabelOptionsBuilder::new()
414            .allow_almost_solved(true)
415            .build()?;
416
417        let mut tc_sum_ra = 0.0;
418        let mut tc_sum_lp = 0.0;
419        let mut tf_sum_ra = 0.0;
420        let mut tf_sum_lp = 0.0;
421        for i_exp in 0..n_exp {
422            let (
423                time_topp_ra,
424                time_topp3_lp,
425                time_interpolation,
426                t_motion_ra,
427                t_motion_lp,
428                s_t_len,
429            ) = run_one_topp3_lp_case(&options_lp)?;
430
431            if flag_print_step {
432                crate::verbosity_log!(
433                    crate::diag::Verbosity::Summary,
434                    "Exp #{}: tc_ra = {:.4} ms, tc_lp = {:.4} ms, tc_interpolation = {:.4} ms, tf_ra = {:.6}, tf_lp = {:.6}, s_t.len() = {}",
435                    i_exp + 1,
436                    time_topp_ra,
437                    time_topp3_lp,
438                    time_interpolation,
439                    t_motion_ra,
440                    t_motion_lp,
441                    s_t_len,
442                );
443            }
444
445            tc_sum_ra += time_topp_ra;
446            tc_sum_lp += time_topp3_lp;
447            tf_sum_ra += t_motion_ra;
448            tf_sum_lp += t_motion_lp;
449        }
450
451        crate::verbosity_log!(
452            crate::diag::Verbosity::Summary,
453            "Average over {} experiments: tc_ra = {:.4} ms, tc_lp = {:.4} ms, tf_ra = {:.6}, tf_lp = {:.6}",
454            n_exp,
455            tc_sum_ra / n_exp as f64,
456            tc_sum_lp / n_exp as f64,
457            tf_sum_ra / n_exp as f64,
458            tf_sum_lp / n_exp as f64,
459        );
460
461        Ok(())
462    }
463}